🎖️GitЯра🎖️
Commit 492afbde5c98f3efdcd367ab2f13ef3665fe7388
Parents : bc7634b
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T19:39:54Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T19:39:54Z
fix(telemetry): repoint 1-Wire temperature at per-channel fields, adopt ADC voltage (#6653)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Changes
21 files changed, 823 insertions(+), 140 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 6b9c8dcb83..7f2c8efb93 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -27,6 +27,7 @@ action_translate_message
actions
adc_multiplier_override
adc_multiplier_override_ratio
+adc_voltage
### ADD ###
add
add_a_note
@@ -1093,6 +1094,7 @@ message_status_sfpp_routing
message_status_unknown
message_translated_label
messages
+metric_channel_label
micrograms_per_cubic_meter
min
minimum_broadcast_seconds
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
index e741485d5b..16350420da 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt
@@ -33,10 +33,17 @@ import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
+import org.meshtastic.core.model.util.TELEMETRY_CHANNEL_COUNT
+import org.meshtastic.core.model.util.adcVoltage
+import org.meshtastic.core.model.util.oneWireTemperature
+import org.meshtastic.core.model.util.withAdcVoltage
+import org.meshtastic.core.model.util.withLegacyOneWireTemperatures
+import org.meshtastic.core.model.util.withOneWireTemperature
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshLogRepository.Companion.DEFAULT_MAX_LOGS
import org.meshtastic.core.repository.MeshLogRetention
+import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
@@ -115,7 +122,6 @@ open class MeshLogRepositoryImpl(
.distinctUntilChanged()
.conflate()
- @Suppress("CyclomaticComplexMethod")
private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching {
val decoded = log.fromRadio.packet?.decoded ?: return@runCatching null
// Requests for telemetry (want_response = true) should not be logged as data points.
@@ -124,22 +130,7 @@ open class MeshLogRepositoryImpl(
val telemetry = Telemetry.ADAPTER.decode(decoded.payload)
telemetry.copy(
time = (log.received_date / MILLIS_PER_SEC).toInt(),
- environment_metrics =
- telemetry.environment_metrics?.let { metrics ->
- metrics.copy(
- temperature = metrics.temperature ?: Float.NaN,
- relative_humidity = metrics.relative_humidity ?: Float.NaN,
- soil_temperature = metrics.soil_temperature ?: Float.NaN,
- barometric_pressure = metrics.barometric_pressure ?: Float.NaN,
- gas_resistance = metrics.gas_resistance ?: Float.NaN,
- voltage = metrics.voltage ?: Float.NaN,
- current = metrics.current ?: Float.NaN,
- lux = metrics.lux ?: Float.NaN,
- uv_lux = metrics.uv_lux ?: Float.NaN,
- iaq = metrics.iaq ?: Int.MIN_VALUE,
- soil_moisture = metrics.soil_moisture ?: Int.MIN_VALUE,
- )
- },
+ environment_metrics = telemetry.environment_metrics?.withSentinelsForAbsentReadings(),
)
}
.getOrNull()
@@ -244,3 +235,35 @@ open class MeshLogRepositoryImpl(
private const val TELEMETRY_SNAPSHOT_PAGE_SIZE = 512
}
}
+
+/**
+ * Replaces absent optional readings with the sentinel the graphing layer filters on, so every field reaches the charts
+ * through one representation.
+ *
+ * Only presence is normalized: a reported `0` is a real reading on every field here and is preserved. Historical logs
+ * predating firmware 2.8 carry 1-Wire temperatures in the deprecated repeated field, so those are lifted onto the
+ * per-channel fields first — see [withLegacyOneWireTemperatures].
+ */
+private fun EnvironmentMetrics.withSentinelsForAbsentReadings(): EnvironmentMetrics =
+ withLegacyOneWireTemperatures().withScalarSentinels().withChannelSentinels()
+
+private fun EnvironmentMetrics.withScalarSentinels(): EnvironmentMetrics = copy(
+ temperature = temperature ?: Float.NaN,
+ relative_humidity = relative_humidity ?: Float.NaN,
+ soil_temperature = soil_temperature ?: Float.NaN,
+ barometric_pressure = barometric_pressure ?: Float.NaN,
+ gas_resistance = gas_resistance ?: Float.NaN,
+ voltage = voltage ?: Float.NaN,
+ current = current ?: Float.NaN,
+ lux = lux ?: Float.NaN,
+ uv_lux = uv_lux ?: Float.NaN,
+ iaq = iaq ?: Int.MIN_VALUE,
+ soil_moisture = soil_moisture ?: Int.MIN_VALUE,
+)
+
+private fun EnvironmentMetrics.withChannelSentinels(): EnvironmentMetrics =
+ (0 until TELEMETRY_CHANNEL_COUNT).fold(this) { metrics, channel ->
+ metrics
+ .withOneWireTemperature(channel, metrics.oneWireTemperature(channel) ?: Float.NaN)
+ .withAdcVoltage(channel, metrics.adcVoltage(channel) ?: Float.NaN)
+ }
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt
index c0ca7c1887..e3428dd117 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt
@@ -29,6 +29,9 @@ import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
+import org.meshtastic.core.model.util.TELEMETRY_CHANNEL_COUNT
+import org.meshtastic.core.model.util.adcVoltage
+import org.meshtastic.core.model.util.oneWireTemperature
import org.meshtastic.core.repository.MeshLogRetention
import org.meshtastic.core.testing.FakeDatabaseProvider
import org.meshtastic.core.testing.FakeMeshLogPrefs
@@ -233,6 +236,53 @@ abstract class CommonMeshLogRepositoryTest {
private fun retentionLog(uuid: String, receivedDate: Long) =
MeshLog(uuid = uuid, message_type = "TEXT", received_date = receivedDate, raw_message = "")
+ @Test
+ fun `parseTelemetryLog lifts legacy one-wire list onto per-channel fields`() = runTest(testDispatcher) {
+ // Firmware before 2.8 emitted the repeated field; stored logs must still chart after the repoint.
+ @Suppress("DEPRECATION")
+ val telemetry =
+ Telemetry(environment_metrics = EnvironmentMetrics(one_wire_temperature = listOf(11f, 0f, 33f)))
+ repository.insert(telemetryLog("legacy-one-wire", 0, telemetry, nowMillis))
+
+ val metrics = repository.getTelemetryFrom(0).first().single().environment_metrics
+ assertNotNull(metrics)
+
+ assertEquals(11f, metrics.oneWireTemperature(0)!!, 0.01f)
+ // A stored 0°C is a real reading, so it must survive the lift rather than reading as absent.
+ assertEquals(0f, metrics.oneWireTemperature(1)!!, 0.01f)
+ assertEquals(33f, metrics.oneWireTemperature(2)!!, 0.01f)
+ // Channels the legacy list never carried normalize to the NaN the charts filter on.
+ assertTrue(metrics.oneWireTemperature(3)!!.isNaN())
+ }
+
+ @Test
+ fun `parseTelemetryLog normalizes absent per-channel readings to NaN`() = runTest(testDispatcher) {
+ val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 21f))
+ repository.insert(telemetryLog("absent-channels", 0, telemetry, nowMillis))
+
+ val metrics = repository.getTelemetryFrom(0).first().single().environment_metrics
+ assertNotNull(metrics)
+
+ for (channel in 0 until TELEMETRY_CHANNEL_COUNT) {
+ assertTrue(metrics.oneWireTemperature(channel)!!.isNaN(), "1-Wire ch$channel should be NaN")
+ assertTrue(metrics.adcVoltage(channel)!!.isNaN(), "ADC ch$channel should be NaN")
+ }
+ }
+
+ @Test
+ fun `parseTelemetryLog preserves zero per-channel readings`() = runTest(testDispatcher) {
+ // 0 V on an unloaded ADC input and 0°C on a probe are measurements, not "no sensor" sentinels.
+ val telemetry =
+ Telemetry(environment_metrics = EnvironmentMetrics(one_wire_temperature_ch0 = 0f, adc_voltage_ch0 = 0f))
+ repository.insert(telemetryLog("zero-channels", 0, telemetry, nowMillis))
+
+ val metrics = repository.getTelemetryFrom(0).first().single().environment_metrics
+ assertNotNull(metrics)
+
+ assertEquals(0f, metrics.oneWireTemperature(0)!!, 0.01f)
+ assertEquals(0f, metrics.adcVoltage(0)!!, 0.01f)
+ }
+
private fun telemetryLog(
uuid: String,
nodeNum: Int,
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt
index 1b39c88e65..9851c27ecc 100644
--- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt
@@ -130,6 +130,8 @@ private fun DeviceMetrics.toExport(): DeviceMetricsExport? = DeviceMetricsExport
)
.takeUnless { it == DeviceMetricsExport() }
+// Reads the deprecated repeated one_wire_temperature so pre-2.8 stored telemetry still round-trips.
+@Suppress("DEPRECATION")
private fun EnvironmentMetrics.toExport(): EnvironmentMetricsExport? = EnvironmentMetricsExport(
temperature = temperature,
relativeHumidity = relative_humidity,
@@ -154,6 +156,22 @@ private fun EnvironmentMetrics.toExport(): EnvironmentMetricsExport? = Environme
soilMoisture = soil_moisture,
soilTemperature = soil_temperature,
oneWireTemperature = one_wire_temperature.takeIf { it.isNotEmpty() },
+ oneWireTemperatureCh0 = one_wire_temperature_ch0,
+ oneWireTemperatureCh1 = one_wire_temperature_ch1,
+ oneWireTemperatureCh2 = one_wire_temperature_ch2,
+ oneWireTemperatureCh3 = one_wire_temperature_ch3,
+ oneWireTemperatureCh4 = one_wire_temperature_ch4,
+ oneWireTemperatureCh5 = one_wire_temperature_ch5,
+ oneWireTemperatureCh6 = one_wire_temperature_ch6,
+ oneWireTemperatureCh7 = one_wire_temperature_ch7,
+ adcVoltageCh0 = adc_voltage_ch0,
+ adcVoltageCh1 = adc_voltage_ch1,
+ adcVoltageCh2 = adc_voltage_ch2,
+ adcVoltageCh3 = adc_voltage_ch3,
+ adcVoltageCh4 = adc_voltage_ch4,
+ adcVoltageCh5 = adc_voltage_ch5,
+ adcVoltageCh6 = adc_voltage_ch6,
+ adcVoltageCh7 = adc_voltage_ch7,
)
.takeUnless { it == EnvironmentMetricsExport() }
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt
index 613273e750..e6c98da160 100644
--- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt
@@ -118,7 +118,27 @@ data class EnvironmentMetricsExport(
val rainfall24h: Float? = null,
val soilMoisture: Int? = null,
val soilTemperature: Float? = null,
+ /**
+ * Legacy repeated 1-Wire readings. Firmware 2.8 moved these to [oneWireTemperatureCh0]..[oneWireTemperatureCh7];
+ * retained so exports of telemetry stored before then keep their original shape.
+ */
val oneWireTemperature: List<Float>? = null,
+ val oneWireTemperatureCh0: Float? = null,
+ val oneWireTemperatureCh1: Float? = null,
+ val oneWireTemperatureCh2: Float? = null,
+ val oneWireTemperatureCh3: Float? = null,
+ val oneWireTemperatureCh4: Float? = null,
+ val oneWireTemperatureCh5: Float? = null,
+ val oneWireTemperatureCh6: Float? = null,
+ val oneWireTemperatureCh7: Float? = null,
+ val adcVoltageCh0: Float? = null,
+ val adcVoltageCh1: Float? = null,
+ val adcVoltageCh2: Float? = null,
+ val adcVoltageCh3: Float? = null,
+ val adcVoltageCh4: Float? = null,
+ val adcVoltageCh5: Float? = null,
+ val adcVoltageCh6: Float? = null,
+ val adcVoltageCh7: Float? = null,
)
@Serializable
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TelemetryChannels.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TelemetryChannels.kt
new file mode 100644
index 0000000000..dd8c311f6b
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TelemetryChannels.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+@file:Suppress("MagicNumber")
+
+package org.meshtastic.core.model.util
+
+import org.meshtastic.proto.EnvironmentMetrics
+
+/**
+ * Channel count for the per-channel `EnvironmentMetrics` telemetry fields — `one_wire_temperature_ch0..ch7` and
+ * `adc_voltage_ch0..ch7`.
+ */
+const val TELEMETRY_CHANNEL_COUNT: Int = 8
+
+/**
+ * Index-to-field accessors for the per-channel telemetry fields.
+ *
+ * Wire generates one named property per channel, so indexed access needs an explicit map rather than a list lookup.
+ * Each field is `optional`, making `null` the only "channel absent" signal — a returned `0f` is a real reading (0 V on
+ * an unloaded ADC input, 0°C on a probe at freezing), so callers must not treat zero as missing.
+ *
+ * Reading past [TELEMETRY_CHANNEL_COUNT] returns `null` rather than throwing, so a firmware that grows the channel
+ * range degrades to "not shown" instead of crashing.
+ */
+fun EnvironmentMetrics.oneWireTemperature(channel: Int): Float? = when (channel) {
+ 0 -> one_wire_temperature_ch0
+ 1 -> one_wire_temperature_ch1
+ 2 -> one_wire_temperature_ch2
+ 3 -> one_wire_temperature_ch3
+ 4 -> one_wire_temperature_ch4
+ 5 -> one_wire_temperature_ch5
+ 6 -> one_wire_temperature_ch6
+ 7 -> one_wire_temperature_ch7
+ else -> null
+}
+
+fun EnvironmentMetrics.adcVoltage(channel: Int): Float? = when (channel) {
+ 0 -> adc_voltage_ch0
+ 1 -> adc_voltage_ch1
+ 2 -> adc_voltage_ch2
+ 3 -> adc_voltage_ch3
+ 4 -> adc_voltage_ch4
+ 5 -> adc_voltage_ch5
+ 6 -> adc_voltage_ch6
+ 7 -> adc_voltage_ch7
+ else -> null
+}
+
+/** Returns a copy with 1-Wire [channel] set to [value]; an out-of-range [channel] is a no-op. */
+fun EnvironmentMetrics.withOneWireTemperature(channel: Int, value: Float?): EnvironmentMetrics = when (channel) {
+ 0 -> copy(one_wire_temperature_ch0 = value)
+ 1 -> copy(one_wire_temperature_ch1 = value)
+ 2 -> copy(one_wire_temperature_ch2 = value)
+ 3 -> copy(one_wire_temperature_ch3 = value)
+ 4 -> copy(one_wire_temperature_ch4 = value)
+ 5 -> copy(one_wire_temperature_ch5 = value)
+ 6 -> copy(one_wire_temperature_ch6 = value)
+ 7 -> copy(one_wire_temperature_ch7 = value)
+ else -> this
+}
+
+/** Returns a copy with ADC [channel] set to [value]; an out-of-range [channel] is a no-op. */
+fun EnvironmentMetrics.withAdcVoltage(channel: Int, value: Float?): EnvironmentMetrics = when (channel) {
+ 0 -> copy(adc_voltage_ch0 = value)
+ 1 -> copy(adc_voltage_ch1 = value)
+ 2 -> copy(adc_voltage_ch2 = value)
+ 3 -> copy(adc_voltage_ch3 = value)
+ 4 -> copy(adc_voltage_ch4 = value)
+ 5 -> copy(adc_voltage_ch5 = value)
+ 6 -> copy(adc_voltage_ch6 = value)
+ 7 -> copy(adc_voltage_ch7 = value)
+ else -> this
+}
+
+/**
+ * Lifts the deprecated `repeated one_wire_temperature` list onto the per-channel fields, for telemetry stored before
+ * firmware 2.8 moved to `one_wire_temperature_chN`.
+ *
+ * Upstream flipped field 23 to `FT_IGNORE`, so current firmware never emits it and this is a read path for historical
+ * logs only. Channels already carrying a per-channel value win, so a live packet is never overwritten by legacy data.
+ */
+@Suppress("DEPRECATION")
+fun EnvironmentMetrics.withLegacyOneWireTemperatures(): EnvironmentMetrics =
+ one_wire_temperature.take(TELEMETRY_CHANNEL_COUNT).foldIndexed(this) { channel, metrics, legacy ->
+ if (metrics.oneWireTemperature(channel) == null) {
+ metrics.withOneWireTemperature(channel, legacy)
+ } else {
+ metrics
+ }
+ }
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/TelemetryChannelsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/TelemetryChannelsTest.kt
new file mode 100644
index 0000000000..6a3e58efe8
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/TelemetryChannelsTest.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model.util
+
+import org.meshtastic.proto.EnvironmentMetrics
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+/**
+ * The per-channel telemetry fields are `optional`, so `null` is the only "channel absent" signal and a reported `0` is
+ * a real reading. Both cases are pinned here — either alone lets the two states collapse back into one.
+ */
+class TelemetryChannelsTest {
+
+ @Test
+ fun oneWireAccessorReadsEveryChannelInOrder() {
+ val metrics =
+ EnvironmentMetrics(
+ one_wire_temperature_ch0 = 0f,
+ one_wire_temperature_ch1 = 1f,
+ one_wire_temperature_ch2 = 2f,
+ one_wire_temperature_ch3 = 3f,
+ one_wire_temperature_ch4 = 4f,
+ one_wire_temperature_ch5 = 5f,
+ one_wire_temperature_ch6 = 6f,
+ one_wire_temperature_ch7 = 7f,
+ )
+
+ for (channel in 0 until TELEMETRY_CHANNEL_COUNT) {
+ assertEquals(channel.toFloat(), metrics.oneWireTemperature(channel))
+ }
+ }
+
+ @Test
+ fun adcAccessorReadsEveryChannelInOrder() {
+ val metrics =
+ EnvironmentMetrics(
+ adc_voltage_ch0 = 0f,
+ adc_voltage_ch1 = 1f,
+ adc_voltage_ch2 = 2f,
+ adc_voltage_ch3 = 3f,
+ adc_voltage_ch4 = 4f,
+ adc_voltage_ch5 = 5f,
+ adc_voltage_ch6 = 6f,
+ adc_voltage_ch7 = 7f,
+ )
+
+ for (channel in 0 until TELEMETRY_CHANNEL_COUNT) {
+ assertEquals(channel.toFloat(), metrics.adcVoltage(channel))
+ }
+ }
+
+ @Test
+ fun measuredZeroIsDistinctFromAbsentChannel() {
+ val reported = EnvironmentMetrics(one_wire_temperature_ch3 = 0f, adc_voltage_ch3 = 0f)
+
+ assertEquals(0f, reported.oneWireTemperature(3))
+ assertEquals(0f, reported.adcVoltage(3))
+ assertNull(EnvironmentMetrics().oneWireTemperature(3))
+ assertNull(EnvironmentMetrics().adcVoltage(3))
+ }
+
+ @Test
+ fun outOfRangeChannelIsNullRatherThanAnError() {
+ val metrics = EnvironmentMetrics(one_wire_temperature_ch0 = 1f, adc_voltage_ch0 = 1f)
+
+ assertNull(metrics.oneWireTemperature(TELEMETRY_CHANNEL_COUNT))
+ assertNull(metrics.adcVoltage(TELEMETRY_CHANNEL_COUNT))
+ assertNull(metrics.oneWireTemperature(-1))
+ assertNull(metrics.adcVoltage(-1))
+ }
+
+ @Test
+ fun withAccessorsSetOnlyTheTargetChannel() {
+ val metrics = EnvironmentMetrics().withOneWireTemperature(2, 12.5f).withAdcVoltage(5, 1.8f)
+
+ assertEquals(12.5f, metrics.oneWireTemperature(2))
+ assertEquals(1.8f, metrics.adcVoltage(5))
+ assertNull(metrics.oneWireTemperature(1))
+ assertNull(metrics.adcVoltage(4))
+ }
+
+ @Test
+ fun withAccessorsIgnoreOutOfRangeChannels() {
+ val metrics = EnvironmentMetrics()
+
+ assertEquals(metrics, metrics.withOneWireTemperature(TELEMETRY_CHANNEL_COUNT, 1f))
+ assertEquals(metrics, metrics.withAdcVoltage(TELEMETRY_CHANNEL_COUNT, 1f))
+ }
+
+ // ---- legacy repeated-field fallback ----
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun legacyListIsLiftedOntoPerChannelFields() {
+ val stored = EnvironmentMetrics(one_wire_temperature = listOf(10f, 0f, 30f))
+
+ val lifted = stored.withLegacyOneWireTemperatures()
+
+ assertEquals(10f, lifted.oneWireTemperature(0))
+ // A stored 0°C is a real historical reading, so it must survive the lift.
+ assertEquals(0f, lifted.oneWireTemperature(1))
+ assertEquals(30f, lifted.oneWireTemperature(2))
+ assertNull(lifted.oneWireTemperature(3))
+ }
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun perChannelValuesWinOverLegacyList() {
+ val mixed = EnvironmentMetrics(one_wire_temperature = listOf(10f, 20f), one_wire_temperature_ch0 = 99f)
+
+ val lifted = mixed.withLegacyOneWireTemperatures()
+
+ assertEquals(99f, lifted.oneWireTemperature(0))
+ assertEquals(20f, lifted.oneWireTemperature(1))
+ }
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun legacyListLongerThanTheChannelRangeIsTruncated() {
+ val stored = EnvironmentMetrics(one_wire_temperature = List(12) { it.toFloat() })
+
+ val lifted = stored.withLegacyOneWireTemperatures()
+
+ assertEquals(7f, lifted.oneWireTemperature(TELEMETRY_CHANNEL_COUNT - 1))
+ assertNull(lifted.oneWireTemperature(TELEMETRY_CHANNEL_COUNT))
+ }
+
+ @Test
+ fun absentLegacyListLeavesMetricsUnchanged() {
+ val metrics = EnvironmentMetrics(temperature = 21f)
+
+ assertEquals(metrics, metrics.withLegacyOneWireTemperatures())
+ }
+}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index bc1d1fbbb8..5370b0112c 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -45,6 +45,7 @@
<string name="actions">Actions</string>
<string name="adc_multiplier_override">ADC multiplier override</string>
<string name="adc_multiplier_override_ratio">ADC multiplier override ratio</string>
+ <string name="adc_voltage">ADC Voltage</string>
<!-- ADD -->
<string name="add">Add</string>
<string name="add_a_note">Add a private note…</string>
@@ -1126,6 +1127,7 @@
<string name="message_status_unknown">Unknown</string>
<string name="message_translated_label">Translated</string>
<string name="messages">Messages</string>
+ <string name="metric_channel_label">%1$s %2$d</string>
<string name="micrograms_per_cubic_meter">µg/m³</string>
<string name="min">Min</string>
<string name="minimum_broadcast_seconds">Minimum broadcast (seconds)</string>
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
index 035c95f595..a528a0e20c 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
@@ -152,6 +152,17 @@ object GraphColors {
val SkyBlue = Color(0xFF03A9F4)
val Chartreuse = Color(0xFF76FF03)
val Coral = Color(0xFFFF6E40)
+
+ // Muted second tier. The vivid hues above are exhausted, so multi-channel series that need their own identity draw
+ // from here: a chart marker resolves its label by series color, so no two plotted series may share one.
+ val Brown = Color(0xFF8D6E63)
+ val BlueGrey = Color(0xFF78909C)
+ val Olive = Color(0xFF9E9D24)
+ val DeepPurple = Color(0xFF7E57C2)
+ val SeaGreen = Color(0xFF2E9E5B)
+ val Maroon = Color(0xFFA13D63)
+ val Mustard = Color(0xFFC9A227)
+ val Slate = Color(0xFF4A6572)
}
object StatusColors {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
index 3014fa7411..c921a8081c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
@@ -20,11 +20,15 @@ import androidx.compose.runtime.Composable
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.util.TELEMETRY_CHANNEL_COUNT
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.UnitConversions.toTempString
+import org.meshtastic.core.model.util.adcVoltage
+import org.meshtastic.core.model.util.oneWireTemperature
import org.meshtastic.core.model.util.toSmallDistanceString
import org.meshtastic.core.model.util.toSpeedString
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.adc_voltage
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.dew_point
import org.meshtastic.core.resources.distance
@@ -32,6 +36,7 @@ import org.meshtastic.core.resources.gas_resistance
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.ic_dew_point
+import org.meshtastic.core.resources.ic_electric_bolt
import org.meshtastic.core.resources.ic_radioactive
import org.meshtastic.core.resources.ic_soil_moisture
import org.meshtastic.core.resources.ic_soil_temperature
@@ -240,19 +245,38 @@ internal fun EnvironmentMetrics(
.asGroup(),
)
}
- // 1-Wire temperature sensors (up to 8 channels) — independent probes, so one card each.
- one_wire_temperature
- .filterNot { it.isNaN() }
- .forEachIndexed { idx, temp ->
- add(
- DrawableMetricInfo(
- label = Res.string.one_wire_temperature,
- value = "${idx + 1}: ${temp.toTempString(isFahrenheit)}",
- icon = Res.drawable.ic_soil_temperature,
+ // 1-Wire probes and ADC inputs are independent channels, so one card each. Absent channels are null; a
+ // reported 0°C or 0 V is a real reading and stays visible.
+ for (channel in 0 until TELEMETRY_CHANNEL_COUNT) {
+ oneWireTemperature(channel)
+ ?.takeIf { !it.isNaN() }
+ ?.let { temp ->
+ add(
+ DrawableMetricInfo(
+ label = Res.string.one_wire_temperature,
+ value = temp.toTempString(isFahrenheit),
+ icon = Res.drawable.ic_soil_temperature,
+ channelNumber = channel + 1,
+ )
+ .asGroup(),
)
- .asGroup(),
- )
- }
+ }
+ }
+ for (channel in 0 until TELEMETRY_CHANNEL_COUNT) {
+ adcVoltage(channel)
+ ?.takeIf { !it.isNaN() }
+ ?.let { volts ->
+ add(
+ DrawableMetricInfo(
+ label = Res.string.adc_voltage,
+ value = MetricFormatter.voltage(volts),
+ icon = Res.drawable.ic_electric_bolt,
+ channelNumber = channel + 1,
+ )
+ .asGroup(),
+ )
+ }
+ }
}
}
MetricCardFlow(groups = groups)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt
index dbfff72057..4084837b58 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt
@@ -28,6 +28,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.metric_channel_label
import org.meshtastic.feature.node.model.DrawableMetricInfo
import org.meshtastic.feature.node.model.MetricInfo
import org.meshtastic.feature.node.model.VectorMetricInfo
@@ -99,7 +101,9 @@ internal fun MetricCardFlow(
@Composable
private fun MetricCard(metric: MetricInfo, valueColor: Color?) {
val cardModifier = Modifier.fillMaxWidth()
- val label = stringResource(metric.label)
+ val label =
+ metric.channelNumber?.let { stringResource(Res.string.metric_channel_label, stringResource(metric.label), it) }
+ ?: stringResource(metric.label)
val resolvedValueColor = valueColor ?: MaterialTheme.colorScheme.onSurface
when (metric) {
is VectorMetricInfo ->
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
index 7c5428da99..509049defb 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
@@ -63,6 +63,7 @@ import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.info
+import org.meshtastic.core.resources.metric_channel_label
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.snr
import org.meshtastic.core.ui.icon.Info
@@ -139,10 +140,16 @@ data class LegendData(
val color: Color,
val isLine: Boolean = false,
val metricKey: Any? = null,
- /** When non-null, overrides the resolved [nameRes] string in the legend label. */
- val labelOverride: String? = null,
+ /** 1-based channel number appended to the resolved [nameRes], for multi-channel series. */
+ val channelNumber: Int? = null,
)
+/** Resolves a legend's display label, appending [LegendData.channelNumber] when the series is one of several. */
+@Composable
+fun legendLabel(data: LegendData): String =
+ data.channelNumber?.let { stringResource(Res.string.metric_channel_label, stringResource(data.nameRes), it) }
+ ?: stringResource(data.nameRes)
+
data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color)
/**
@@ -167,7 +174,7 @@ fun Legend(
) {
legendData.forEachIndexed { index, data ->
val isVisible = index !in hiddenSet
- val label = data.labelOverride ?: stringResource(data.nameRes)
+ val label = legendLabel(data)
if (onToggle != null) {
FilterChip(
selected = isVisible,
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
index caf974c873..65cfd93717 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
@@ -36,10 +36,10 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineModel
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.common.data.ExtraStore
-import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.adc_voltage
import org.meshtastic.core.resources.baro_pressure
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
@@ -116,25 +116,26 @@ private val LEGEND_DATA_3 =
)
private val LEGEND_DATA_4 =
- listOf(
- Environment.ONE_WIRE_TEMP_1,
- Environment.ONE_WIRE_TEMP_2,
- Environment.ONE_WIRE_TEMP_3,
- Environment.ONE_WIRE_TEMP_4,
- Environment.ONE_WIRE_TEMP_5,
- Environment.ONE_WIRE_TEMP_6,
- Environment.ONE_WIRE_TEMP_7,
- Environment.ONE_WIRE_TEMP_8,
- )
- .mapIndexed { index, entry ->
- LegendData(
- nameRes = Res.string.one_wire_temperature,
- labelOverride = "1-Wire Temp ${index + 1}",
- color = entry.color,
- isLine = true,
- metricKey = entry,
- )
- }
+ Environment.oneWireTemperatures.mapIndexed { index, entry ->
+ LegendData(
+ nameRes = Res.string.one_wire_temperature,
+ channelNumber = index + 1,
+ color = entry.color,
+ isLine = true,
+ metricKey = entry,
+ )
+ }
+
+private val LEGEND_DATA_5 =
+ Environment.adcVoltages.mapIndexed { index, entry ->
+ LegendData(
+ nameRes = Res.string.adc_voltage,
+ channelNumber = index + 1,
+ color = entry.color,
+ isLine = true,
+ metricKey = entry,
+ )
+ }
private const val PRESSURE_DEFAULT_MIN = 950.0
private const val PRESSURE_DEFAULT_MAX = 1050.0
@@ -168,20 +169,14 @@ internal fun chartValue(metric: Environment, telemetry: Telemetry, isImperial: B
* Unit suffix for a plotted metric's axis and marker labels, in the user's display units, or "" for metrics whose unit
* would be noise on a shared axis. Includes any leading space, so it appends directly to a formatted value.
*/
-internal fun unitSuffix(metric: Environment, isFahrenheit: Boolean, isImperial: Boolean): String = when (metric) {
- Environment.TEMPERATURE,
- Environment.SOIL_TEMPERATURE,
- Environment.ONE_WIRE_TEMP_1,
- Environment.ONE_WIRE_TEMP_2,
- Environment.ONE_WIRE_TEMP_3,
- Environment.ONE_WIRE_TEMP_4,
- Environment.ONE_WIRE_TEMP_5,
- Environment.ONE_WIRE_TEMP_6,
- Environment.ONE_WIRE_TEMP_7,
- Environment.ONE_WIRE_TEMP_8,
- -> if (isFahrenheit) "°F" else "°C"
+internal fun unitSuffix(metric: Environment, isFahrenheit: Boolean, isImperial: Boolean): String = when {
+ metric == Environment.TEMPERATURE ||
+ metric == Environment.SOIL_TEMPERATURE ||
+ metric in Environment.oneWireTemperatures -> if (isFahrenheit) "°F" else "°C"
+
+ metric in Environment.adcVoltages -> " V"
- Environment.WIND_SPEED -> if (isImperial) " mph" else " m/s"
+ metric == Environment.WIND_SPEED -> if (isImperial) " mph" else " m/s"
else -> ""
}
@@ -208,7 +203,7 @@ fun EnvironmentMetricsChart(
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
val allLegendData =
- (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4).filter {
+ (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4 + LEGEND_DATA_5).filter {
graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0]
}
@@ -219,7 +214,7 @@ fun EnvironmentMetricsChart(
allLegendData.indices.filter { (allLegendData[it].metricKey as? Environment) in hiddenMetrics }.toSet()
}
- val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) }
+ val colorToLabel = allLegendData.associate { it.color to legendLabel(it) }
val colorToUnit =
allLegendData.associate { legend ->
val metric = legend.metricKey as? Environment
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
index 7a6d1a5a63..b11d2cee17 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
@@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
@@ -43,17 +44,23 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.MetricFormatter
+import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
+import org.meshtastic.core.model.util.adcVoltage
+import org.meshtastic.core.model.util.oneWireTemperature
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.adc_voltage
import org.meshtastic.core.resources.current
+import org.meshtastic.core.resources.device_metrics_label_value
import org.meshtastic.core.resources.env_metrics_log
import org.meshtastic.core.resources.gas_resistance
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.iaq_definition
import org.meshtastic.core.resources.lux
+import org.meshtastic.core.resources.metric_channel_label
import org.meshtastic.core.resources.one_wire_temperature
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.rainfall_1h
@@ -479,36 +486,56 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics,
}
}
+/**
+ * One row per reporting 1-Wire probe. Values arrive already converted to the display unit by the view model, so they
+ * are only formatted here — a second conversion would double-count. An absent channel is `null`; 0°C is a real reading.
+ */
@Composable
private fun OneWireTemperatureDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
) {
- val sensors = envMetrics.one_wire_temperature.filterNot { it.isNaN() }
- if (sensors.isEmpty()) return
- val oneWireEntries =
- listOf(
- Environment.ONE_WIRE_TEMP_1,
- Environment.ONE_WIRE_TEMP_2,
- Environment.ONE_WIRE_TEMP_3,
- Environment.ONE_WIRE_TEMP_4,
- Environment.ONE_WIRE_TEMP_5,
- Environment.ONE_WIRE_TEMP_6,
- Environment.ONE_WIRE_TEMP_7,
- Environment.ONE_WIRE_TEMP_8,
+ val unit = if (environmentDisplayFahrenheit) "°F" else "°C"
+ Environment.oneWireTemperatures.forEachIndexed { idx, entry ->
+ val temp = envMetrics.oneWireTemperature(idx)?.takeIf { !it.isNaN() } ?: return@forEachIndexed
+ ChannelMetricRow(
+ color = entry.color,
+ label = stringResource(Res.string.one_wire_temperature),
+ channelNumber = idx + 1,
+ value = "${NumberFormatter.format(temp, 1)}$unit",
+ )
+ }
+}
+
+/** One row per reporting ADC channel. Volts need no unit conversion, and 0 V is a real reading. */
+@Composable
+private fun AdcVoltageDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
+ Environment.adcVoltages.forEachIndexed { idx, entry ->
+ val volts = envMetrics.adcVoltage(idx)?.takeIf { !it.isNaN() } ?: return@forEachIndexed
+ ChannelMetricRow(
+ color = entry.color,
+ label = stringResource(Res.string.adc_voltage),
+ channelNumber = idx + 1,
+ value = MetricFormatter.voltage(volts),
+ )
+ }
+}
+
+@Composable
+private fun ChannelMetricRow(color: Color, label: String, channelNumber: Int, value: String) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text =
+ stringResource(
+ Res.string.device_metrics_label_value,
+ stringResource(Res.string.metric_channel_label, label, channelNumber),
+ value,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.labelLarge,
)
- val textFormat = if (environmentDisplayFahrenheit) "%s %d: %.1f°F" else "%s %d: %.1f°C"
- sensors.forEachIndexed { idx, temp ->
- val color = oneWireEntries.getOrNull(idx)?.color ?: Environment.ONE_WIRE_TEMP_1.color
- Row(verticalAlignment = Alignment.CenterVertically) {
- MetricIndicator(color)
- Spacer(Modifier.width(4.dp))
- Text(
- text = formatString(textFormat, stringResource(Res.string.one_wire_temperature), idx + 1, temp),
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.labelLarge,
- )
- }
}
}
@@ -560,6 +587,7 @@ private fun EnvironmentMetricsContent(
WindDisplay(envMetrics, isImperial)
RainfallDisplay(envMetrics, isImperial)
OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit)
+ AdcVoltageDisplay(envMetrics)
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
index 2b86bcee9e..9977a59ce4 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
@@ -20,10 +20,13 @@ import androidx.compose.ui.graphics.Color
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.ui.theme.GraphColors.Amber
import org.meshtastic.core.ui.theme.GraphColors.Blue
+import org.meshtastic.core.ui.theme.GraphColors.BlueGrey
+import org.meshtastic.core.ui.theme.GraphColors.Brown
import org.meshtastic.core.ui.theme.GraphColors.Chartreuse
import org.meshtastic.core.ui.theme.GraphColors.Coral
import org.meshtastic.core.ui.theme.GraphColors.Cyan
import org.meshtastic.core.ui.theme.GraphColors.DeepOrange
+import org.meshtastic.core.ui.theme.GraphColors.DeepPurple
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Indigo
@@ -31,11 +34,16 @@ import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.core.ui.theme.GraphColors.LightGreen
import org.meshtastic.core.ui.theme.GraphColors.Lime
import org.meshtastic.core.ui.theme.GraphColors.Magenta
+import org.meshtastic.core.ui.theme.GraphColors.Maroon
+import org.meshtastic.core.ui.theme.GraphColors.Mustard
+import org.meshtastic.core.ui.theme.GraphColors.Olive
import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Pink
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.core.ui.theme.GraphColors.Red
+import org.meshtastic.core.ui.theme.GraphColors.SeaGreen
import org.meshtastic.core.ui.theme.GraphColors.SkyBlue
+import org.meshtastic.core.ui.theme.GraphColors.Slate
import org.meshtastic.core.ui.theme.GraphColors.Teal
import org.meshtastic.proto.Telemetry
@@ -77,39 +85,83 @@ enum class Environment(val color: Color) {
override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.radiation
},
ONE_WIRE_TEMP_1(Amber) {
- override fun getValue(telemetry: Telemetry): Float? =
- telemetry.environment_metrics?.one_wire_temperature?.getOrNull(0)
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch0
},
ONE_WIRE_TEMP_2(DeepOrange) {
- override fun getValue(telemetry: Telemetry): Float? =
- telemetry.environment_metrics?.one_wire_temperature?.getOrNull(1)
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch1
},
ONE_WIRE_TEMP_3(Indigo) {
- override fun getValue(telemetry: Telemetry): Float? =
- telemetry.environment_metrics?.one_wire_temperature?.getOrNull(2)
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch2
},
ONE_WIRE_TEMP_4(LightGreen) {
- override fun getValue(telemetry: Telemetry): Float? =
- telemetry.environment_metrics?.one_wire_temperature?.getOrNull(3)
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch3
},
ONE_WIRE_TEMP_5(Magenta) {
- override fun getValue(telemetry: Telemetry): Float? =
- telemetry.environment_metrics?.one_wire_temperature?.getOrNull(4)
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch4
},
ONE_WIRE_TEMP_6(SkyBlue) {
- override fun getValue(telemetry: Telemetry): Float? =
- telemetry.environment_metrics?.one_wire_temperature?.getOrNull(5)
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch5
},
ONE_WIRE_TEMP_7(Chartreuse) {
- override fun getValue(telemetry: Telemetry): Float? =
- telemetry.environment_metrics?.one_wire_temperature?.getOrNull(6)
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch6
},
ONE_WIRE_TEMP_8(Coral) {
- override fun getValue(telemetry: Telemetry): Float? =
- telemetry.environment_metrics?.one_wire_temperature?.getOrNull(7)
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch7
+ },
+ ADC_VOLTAGE_1(Brown) {
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch0
+ },
+ ADC_VOLTAGE_2(BlueGrey) {
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch1
+ },
+ ADC_VOLTAGE_3(Olive) {
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch2
+ },
+ ADC_VOLTAGE_4(DeepPurple) {
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch3
+ },
+ ADC_VOLTAGE_5(SeaGreen) {
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch4
+ },
+ ADC_VOLTAGE_6(Maroon) {
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch5
+ },
+ ADC_VOLTAGE_7(Mustard) {
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch6
+ },
+ ADC_VOLTAGE_8(Slate) {
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch7
}, ;
abstract fun getValue(telemetry: Telemetry): Float?
+
+ companion object {
+ /** The 1-Wire probe series, ordered to match `one_wire_temperature_ch0..ch7`. */
+ val oneWireTemperatures: List<Environment> =
+ listOf(
+ ONE_WIRE_TEMP_1,
+ ONE_WIRE_TEMP_2,
+ ONE_WIRE_TEMP_3,
+ ONE_WIRE_TEMP_4,
+ ONE_WIRE_TEMP_5,
+ ONE_WIRE_TEMP_6,
+ ONE_WIRE_TEMP_7,
+ ONE_WIRE_TEMP_8,
+ )
+
+ /** The multi-channel ADC series, ordered to match `adc_voltage_ch0..ch7`. */
+ val adcVoltages: List<Environment> =
+ listOf(
+ ADC_VOLTAGE_1,
+ ADC_VOLTAGE_2,
+ ADC_VOLTAGE_3,
+ ADC_VOLTAGE_4,
+ ADC_VOLTAGE_5,
+ ADC_VOLTAGE_6,
+ ADC_VOLTAGE_7,
+ ADC_VOLTAGE_8,
+ )
+ }
}
/**
@@ -246,18 +298,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emp
}
// 1-Wire temperature sensors (up to 8 channels, Fahrenheit-aware)
- val oneWireEntries =
- listOf(
- Environment.ONE_WIRE_TEMP_1,
- Environment.ONE_WIRE_TEMP_2,
- Environment.ONE_WIRE_TEMP_3,
- Environment.ONE_WIRE_TEMP_4,
- Environment.ONE_WIRE_TEMP_5,
- Environment.ONE_WIRE_TEMP_6,
- Environment.ONE_WIRE_TEMP_7,
- Environment.ONE_WIRE_TEMP_8,
- )
- oneWireEntries.forEach { entry ->
+ Environment.oneWireTemperatures.forEach { entry ->
val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } }
if (values.isNotEmpty()) {
var minVal = values.minOf { it }
@@ -272,6 +313,17 @@ data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emp
}
}
+ // Multi-channel ADC voltage (up to 8 channels). Volts need no unit conversion. 0 V is a real reading, so only
+ // NaN is filtered here.
+ Environment.adcVoltages.forEach { entry ->
+ val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } }
+ if (values.isNotEmpty()) {
+ minValues.add(values.minOf { it })
+ maxValues.add(values.maxOf { it })
+ shouldPlot[entry.ordinal] = true
+ }
+ }
+
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it }
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index a29bce2f70..c335ec1162 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -48,9 +48,13 @@ import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.GeoConstants
+import org.meshtastic.core.model.util.TELEMETRY_CHANNEL_COUNT
import org.meshtastic.core.model.util.UnitConversions
+import org.meshtastic.core.model.util.adcVoltage
+import org.meshtastic.core.model.util.oneWireTemperature
import org.meshtastic.core.model.util.rxTimeOrNull
import org.meshtastic.core.model.util.snrOrNull
+import org.meshtastic.core.model.util.withOneWireTemperature
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
@@ -145,16 +149,19 @@ open class MetricsViewModel(
if (currentState.isFahrenheit) {
data.map { telemetry ->
val em = telemetry.environment_metrics ?: return@map telemetry
- telemetry.copy(
- environment_metrics =
+ // Each 1-Wire channel converts independently; an absent channel must stay null rather than
+ // becoming a converted zero.
+ var converted =
em.copy(
temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
- soil_temperature =
- em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
- one_wire_temperature =
- em.one_wire_temperature.map { UnitConversions.celsiusToFahrenheit(it) },
- ),
- )
+ soil_temperature = em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
+ )
+ for (channel in 0 until TELEMETRY_CHANNEL_COUNT) {
+ val celsius = em.oneWireTemperature(channel) ?: continue
+ converted =
+ converted.withOneWireTemperature(channel, UnitConversions.celsiusToFahrenheit(celsius))
+ }
+ telemetry.copy(environment_metrics = converted)
}
} else {
data
@@ -427,25 +434,32 @@ open class MetricsViewModel(
}
fun saveEnvironmentMetricsCSV(uri: CommonUri, data: List<Telemetry>) {
- val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" }
+ val oneWireHeaders = (1..TELEMETRY_CHANNEL_COUNT).joinToString(",") { "\"oneWireTemp$it\"" }
+ val adcHeaders = (1..TELEMETRY_CHANNEL_COUNT).joinToString(",") { "\"adcVoltage$it\"" }
exportCsv(
uri = uri,
header =
"\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," +
"\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," +
- "\"soilMoisture\",$oneWireHeaders\n",
+ "\"soilMoisture\",$oneWireHeaders,$adcHeaders\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { t ->
val em = t.environment_metrics
- val owt = em?.one_wire_temperature ?: emptyList()
+ // An absent channel exports as an empty field, keeping it distinguishable from a measured 0°C / 0 V.
val oneWireValues =
- (0 until ONE_WIRE_SENSOR_COUNT).joinToString(",") { i -> "\"${owt.getOrNull(i) ?: ""}\"" }
+ (0 until TELEMETRY_CHANNEL_COUNT).joinToString(",") { i ->
+ "\"${em?.oneWireTemperature(i)?.takeIf { !it.isNaN() } ?: ""}\""
+ }
+ val adcValues =
+ (0 until TELEMETRY_CHANNEL_COUNT).joinToString(",") { i ->
+ "\"${em?.adcVoltage(i)?.takeIf { !it.isNaN() } ?: ""}\""
+ }
"\"${em?.temperature ?: ""}\",\"${em?.relative_humidity ?: ""}\"," +
"\"${em?.barometric_pressure ?: ""}\",\"${em?.gas_resistance ?: ""}\"," +
"\"${em?.iaq ?: ""}\",\"${em?.wind_speed ?: ""}\"," +
"\"${em?.wind_direction ?: ""}\",\"${em?.soil_temperature ?: ""}\"," +
- "\"${em?.soil_moisture ?: ""}\",$oneWireValues"
+ "\"${em?.soil_moisture ?: ""}\",$oneWireValues,$adcValues"
}
}
@@ -562,10 +576,6 @@ open class MetricsViewModel(
}
protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0)
-
- companion object {
- private const val ONE_WIRE_SENSOR_COUNT = 8
- }
}
private fun buildGpx(positions: List<org.meshtastic.proto.Position>, trackName: String): String {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt
index 41aae00aa5..a8f9746204 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt
@@ -25,6 +25,9 @@ internal sealed interface MetricInfo {
val label: StringResource
val value: String
val rotateIcon: Float
+
+ /** 1-based channel number appended to the resolved [label], for one-card-per-channel readings. */
+ val channelNumber: Int?
}
internal data class VectorMetricInfo(
@@ -32,6 +35,7 @@ internal data class VectorMetricInfo(
override val value: String,
val icon: ImageVector,
override val rotateIcon: Float = 0f,
+ override val channelNumber: Int? = null,
) : MetricInfo
internal data class DrawableMetricInfo(
@@ -39,4 +43,5 @@ internal data class DrawableMetricInfo(
override val value: String,
val icon: DrawableResource,
override val rotateIcon: Float = 0f,
+ override val channelNumber: Int? = null,
) : MetricInfo
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentChartUnitsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentChartUnitsTest.kt
index 2c631b3676..900ce393cb 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentChartUnitsTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentChartUnitsTest.kt
@@ -83,6 +83,20 @@ class EnvironmentChartUnitsTest {
assertEquals("°F", unitSuffix(Environment.ONE_WIRE_TEMP_8, isFahrenheit = true, isImperial = false))
}
+ @Test
+ fun adcVoltageSuffixIsVoltsRegardlessOfDisplayUnits() {
+ assertEquals(" V", unitSuffix(Environment.ADC_VOLTAGE_1, isFahrenheit = false, isImperial = false))
+ assertEquals(" V", unitSuffix(Environment.ADC_VOLTAGE_8, isFahrenheit = true, isImperial = true))
+ }
+
+ /** ADC readings are already in volts, so the chart must not unit-convert them. */
+ @Test
+ fun adcVoltageIsNotConverted() {
+ val t = telemetry(EnvironmentMetrics(adc_voltage_ch0 = 3.3f))
+
+ assertEquals(3.3f, chartValue(Environment.ADC_VOLTAGE_1, t, isImperial = true)!!, 0.001f)
+ }
+
@Test
fun sharedAxisMetricsHaveNoSuffix() {
assertEquals("", unitSuffix(Environment.HUMIDITY, isFahrenheit = true, isImperial = true))
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt
index f3185f226f..d1f0fa7f4b 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt
@@ -217,6 +217,75 @@ class EnvironmentMetricsForGraphingTest {
assertEquals(0.15f, result.rightMinMax.second, 0.01f)
}
+ // ---- Per-channel series (1-Wire probes, ADC inputs) ----
+
+ @Test
+ fun oneWireChannels_plotIndependently() {
+ val metrics =
+ listOf(telemetry(env = EnvironmentMetrics(one_wire_temperature_ch0 = 10f, one_wire_temperature_ch7 = 40f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.ONE_WIRE_TEMP_1.ordinal])
+ assertTrue(result.shouldPlot[Environment.ONE_WIRE_TEMP_8.ordinal])
+ // Channels the node never reported must stay unplotted rather than charting as zero.
+ assertFalse(result.shouldPlot[Environment.ONE_WIRE_TEMP_2.ordinal])
+ }
+
+ @Test
+ fun oneWireChannels_convertToFahrenheit() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(one_wire_temperature_ch2 = 100f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true)
+
+ assertTrue(result.shouldPlot[Environment.ONE_WIRE_TEMP_3.ordinal])
+ assertEquals(212f, result.rightMinMax.second, 0.01f)
+ }
+
+ /** 0°C is a real probe reading, not a "no sensor" sentinel — the series must still plot. */
+ @Test
+ fun oneWireChannel_zeroIsPlotted() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(one_wire_temperature_ch0 = 0f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.ONE_WIRE_TEMP_1.ordinal])
+ }
+
+ @Test
+ fun adcChannels_plotIndependently() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(adc_voltage_ch0 = 3.3f, adc_voltage_ch7 = 1.8f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.ADC_VOLTAGE_1.ordinal])
+ assertTrue(result.shouldPlot[Environment.ADC_VOLTAGE_8.ordinal])
+ assertFalse(result.shouldPlot[Environment.ADC_VOLTAGE_2.ordinal])
+ }
+
+ /** 0 V is a real reading on an unloaded ADC input — the series must still plot. */
+ @Test
+ fun adcChannel_zeroIsPlotted() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(adc_voltage_ch3 = 0f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertTrue(result.shouldPlot[Environment.ADC_VOLTAGE_4.ordinal])
+ assertEquals(0f, result.rightMinMax.first, 0.001f)
+ }
+
+ /** ADC voltages are volts already; the Fahrenheit setting must not touch them. */
+ @Test
+ fun adcChannels_areNotUnitConverted() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(adc_voltage_ch0 = 100f)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true)
+
+ assertEquals(100f, result.rightMinMax.second, 0.01f)
+ }
+
+ @Test
+ fun adcChannel_nanFilteredOut() {
+ val metrics = listOf(telemetry(env = EnvironmentMetrics(adc_voltage_ch0 = Float.NaN)))
+ val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
+
+ assertFalse(result.shouldPlot[Environment.ADC_VOLTAGE_1.ordinal])
+ }
+
// ---- NaN filtering ----
@Test
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
index df7238bff4..9e25dc3347 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt
@@ -297,7 +297,12 @@ class MetricsViewModelTest {
wind_direction = 180,
soil_temperature = 18.75f,
soil_moisture = 65,
- one_wire_temperature = listOf(1f, 2f, 3f),
+ one_wire_temperature_ch0 = 1f,
+ one_wire_temperature_ch1 = 2f,
+ one_wire_temperature_ch2 = 3f,
+ // 0 V is a real reading on an unloaded ADC input, so it exports as 0.0, not empty.
+ adc_voltage_ch0 = 3.3f,
+ adc_voltage_ch1 = 0f,
),
)
@@ -331,12 +336,12 @@ class MetricsViewModelTest {
val csvOutput = buffer.readUtf8()
assertTrue(
csvOutput.startsWith(
- "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\",\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\",\"soilMoisture\",\"oneWireTemp1\",\"oneWireTemp2\",\"oneWireTemp3\",\"oneWireTemp4\",\"oneWireTemp5\",\"oneWireTemp6\",\"oneWireTemp7\",\"oneWireTemp8\"",
+ "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\",\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\",\"soilMoisture\",\"oneWireTemp1\",\"oneWireTemp2\",\"oneWireTemp3\",\"oneWireTemp4\",\"oneWireTemp5\",\"oneWireTemp6\",\"oneWireTemp7\",\"oneWireTemp8\",\"adcVoltage1\",\"adcVoltage2\",\"adcVoltage3\",\"adcVoltage4\",\"adcVoltage5\",\"adcVoltage6\",\"adcVoltage7\",\"adcVoltage8\"",
),
)
assertTrue(
csvOutput.contains(
- "\"21.5\",\"55.5\",\"1013.25\",\"12.3\",\"42\",\"5.5\",\"180\",\"18.75\",\"65\",\"1.0\",\"2.0\",\"3.0\",\"\",\"\",\"\",\"\",\"\"",
+ "\"21.5\",\"55.5\",\"1013.25\",\"12.3\",\"42\",\"5.5\",\"180\",\"18.75\",\"65\",\"1.0\",\"2.0\",\"3.0\",\"\",\"\",\"\",\"\",\"\",\"3.3\",\"0.0\",\"\",\"\",\"\",\"\",\"\",\"\"",
),
)
diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/component/EnvironmentChannelMetricsTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/component/EnvironmentChannelMetricsTest.kt
new file mode 100644
index 0000000000..338492ac6d
--- /dev/null
+++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/component/EnvironmentChannelMetricsTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.test.ComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.meshtastic.core.model.Node
+import org.meshtastic.proto.Config
+import kotlin.test.Test
+import org.meshtastic.proto.EnvironmentMetrics as EnvironmentMetricsProto
+
+/**
+ * The per-channel 1-Wire and ADC readings are Wire-generated and nullable, so `null` is the only "no channel" signal.
+ * Both the absent and the measured-zero case are pinned — either alone lets the two states collapse back into one,
+ * which is how the old `repeated one_wire_temperature` reads used to lose channel identity.
+ */
+@OptIn(ExperimentalTestApi::class)
+class EnvironmentChannelMetricsTest {
+
+ @Test
+ fun zeroOneWireTemperatureIsShown() = runComposeUiTest {
+ setEnvironmentMetrics(EnvironmentMetricsProto(one_wire_temperature_ch0 = 0f))
+ onNodeWithText("1-Wire Temp 1").assertIsDisplayed()
+ onNodeWithText("0°C").assertIsDisplayed()
+ }
+
+ @Test
+ fun absentOneWireChannelIsHidden() = runComposeUiTest {
+ setEnvironmentMetrics(EnvironmentMetricsProto())
+ onNodeWithText("1-Wire Temp 1").assertDoesNotExist()
+ }
+
+ @Test
+ fun oneWireChannelsKeepTheirChannelNumber() = runComposeUiTest {
+ // A gap in the middle must not renumber the channels above it.
+ setEnvironmentMetrics(EnvironmentMetricsProto(one_wire_temperature_ch0 = 10f, one_wire_temperature_ch2 = 30f))
+ onNodeWithText("1-Wire Temp 1").assertIsDisplayed()
+ onNodeWithText("1-Wire Temp 3").assertIsDisplayed()
+ onNodeWithText("1-Wire Temp 2").assertDoesNotExist()
+ }
+
+ @Test
+ fun zeroAdcVoltageIsShown() = runComposeUiTest {
+ setEnvironmentMetrics(EnvironmentMetricsProto(adc_voltage_ch0 = 0f))
+ onNodeWithText("ADC Voltage 1").assertIsDisplayed()
+ onNodeWithText("0.00 V").assertIsDisplayed()
+ }
+
+ @Test
+ fun absentAdcChannelIsHidden() = runComposeUiTest {
+ setEnvironmentMetrics(EnvironmentMetricsProto())
+ onNodeWithText("ADC Voltage 1").assertDoesNotExist()
+ }
+
+ @Test
+ fun adcChannelsKeepTheirChannelNumber() = runComposeUiTest {
+ setEnvironmentMetrics(EnvironmentMetricsProto(adc_voltage_ch1 = 3.3f, adc_voltage_ch7 = 1.8f))
+ onNodeWithText("ADC Voltage 2").assertIsDisplayed()
+ onNodeWithText("3.30 V").assertIsDisplayed()
+ onNodeWithText("ADC Voltage 8").assertIsDisplayed()
+ onNodeWithText("1.80 V").assertIsDisplayed()
+ }
+
+ private fun ComposeUiTest.setEnvironmentMetrics(metrics: EnvironmentMetricsProto) = setContent {
+ MaterialTheme {
+ EnvironmentMetrics(
+ node = Node(num = 1, environmentMetrics = metrics),
+ displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
+ )
+ }
+ }
+}
Served by rngit 1.4.2 - Generated in 0.35s